-- vim: set autoindent noexpandtab tabstop=4 shiftwidth=4 softtabstop=-1:
--[[
	Copyright (c) 2018-2020 James Dornan <james@catch22.com>
	`
    This file is part of MineTest.Me Auth mod.

    Auth Mod is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    Auth mod is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with MineTest.Me auth mod.  If not, see <https://www.gnu.org/licenses/>.
	
--]]
---
-- Auth mod is a custom replacement for auth.txt that uses mtm.storage
-- to store data in a either a postgresql or sqlite3 database. The main
-- avantage I hope to leverage from this mod is to store auth information
-- indipentant from the backend storage, allowing a single account for
-- many servers. It's just easier to recognize people when they have the
-- same name on each server.
--
-- At this time it has only been tested with PostgreSQL, but should work
-- with sqlite3 also.
--
-- @author James Dornan james _at_ catch22.com
-- @copyright James Dornan (GPLv3)
-- @license GPLv3
-- @module auth
-- @see https://dev.minetest.net/minetest.register_authentication_handler
--

-- ldoc -f discount -p 'Minetest Auth Mod' -d <output directory> -a init.lua

-- We do not deal with singleplayer.
-- Will not support older versions of Minetest
if not minetest.settings or minetest.is_singleplayer() then
	minetest.log('info', '[MOD] auth not loading on older Minetest.')
	return
end

--- auth class table
-- @field modname          string  Name of this mod
-- @field max_name_len     int     Maximum length of name string
-- @field max_password_len int     Maximum length of name string
-- @field entries          table   mTm.storage object used to store auth entries
-- @field cache            table   Contains cached auth entries
-- @field cache_on         boolean Used to turn off cache only when debugging
-- @field default_privs    table   Contains the default privs for the server
-- @field admin_set_privs  boolean Flag that indicates if the admin has had they privs saved
-- @field gc_timer         float   Global step times for garbage connection
-- @field cache_hits       int     How many times the cache was used
-- @field database_hits    int     How the database was used
local auth = {
	name             = minetest.get_current_modname(),
	max_name_len     = 32,
	max_password_len = 512,
	entries          = mtm.storage(),
	cache            = {},
	cache_on         = true,
	default_privs    = nil,
	admin_set_privs  = false,
	gc_timer         = 0,
	gc_interval      = 3600,
	cache_hits       = 0,
	database_hits    = 0,
}

--- Functions
-- @section functions

--- Get default privs from minetest.conf and convert to table
-- @return table
auth.get_default_privs = function()
	if type(auth.default_privs) == "table" then
		return auth.default_privs
	end

	auth.default_privs = minetest.string_to_privs(minetest.settings:get("default_privs"))

	return auth.default_privs or {}
end

--- Check to see if a player is the admin
-- @param  string name of player
-- @return boolean
auth.is_admin = function(name)
	return (name == minetest.settings:get("name"))
end

--- Check to see if a player has an auth entry
-- @param  string name of player
-- @return boolean
auth.exists = function(name)
	return (type(auth.get(name)) == 'table')
end

--- Get entry from cache or database
-- Get auth entry from the cache if caching is enabled. If entry not in the
-- cache then it will be selectd from the database and cached.
-- @param  string name of player
-- @return table
auth.get = function(name)
	-- Caching off return entry
	if not auth.cache_on then
		auth.database_hits = auth.database_hits + 1

		return auth.entries[name]
	end

	-- Caching on and we have it 
	if auth.cache[name] then
		auth.cache_hits = auth.cache_hits + 1

		return auth.cache[name]
	end

	-- Get entry from database
	local entry = auth.entries[name]

	-- If entry update cache
	if entry then
		auth.cache[name] = entry
	end

	auth.database_hits = auth.database_hits + 1

	return entry
end

--- Set entry data and cache
-- Creat or update an auth entry. The data table supplied can be all auth fields
-- or a subset. IF caching is enabled the the entry will be cached after a
-- successful update of the database table.
-- @param  string name of player
-- @param  table  auth entry data
-- @return boolean
auth.set = function(name, data)
	minetest.log('info', '[auth] set ' .. name)

	local entry = auth.get(name)

	if type(entry) ~= 'table' then
		entry = {}
	end
	
	-- Update entry with data
	for key, value in pairs(data) do
		entry[key] = value
	end

	-- Save to database
	auth.entries[name] = entry;

	-- Update cache
	if auth.cache_on then
		auth.cache[name] = entry;
	end
end

--- Check to see if a player name is okay
-- @param  string name of player
-- @return boolean
auth.name_ok = function(name)
	if type(name) ~= 'string' then
		minetest.log('errro', '[auth] player name not a string.')
		return false
	end

	if name:len() > auth.max_name_len then
		return false
	end

	return true
end

--- Check to see if a password string is okay
-- @param  string password
-- @return boolean
auth.password_ok = function(password)
	if type(password) ~= 'string' then
		minetest.log('error', '[auth] player password not a string.')
		return false
	end

	if password:len() > auth.max_password_len then
		return false
	end

	return true
end

--- Get auth information for a player from the cache or database
-- If entry exists in cache it will be returned and if it does
-- not(rare) then the entry will be cached as well as returned.
-- @param  string name
-- @return table
auth.get_auth = function(name)
	minetest.log('info', '[auth] get_auth ' .. name)

	if not auth.name_ok(name) then
		minetest.log('error', '[auth] name not ok ' .. name)
		return nil
	end

	if not auth.exists(name) then
		return nil
	end

	local admin      = auth.is_admin(name)
	local privileges = {}

	if not admin or auth.admin_set_privs then
		return auth.get(name)
	end

	for priv, def in pairs(core.registered_privileges) do
		privileges[priv] = true
	end
		
	auth.set_privileges(name, privileges)
	auth.admin_set_privs = true

	return auth.get(name)
end

--- Create new entry for user in the auth table
-- @param  string name
-- @param  string password
-- @param  string reason
-- @return boolean
auth.create_auth = function(name, password, reason)
	minetest.log('info', '[auth] create_auth ' .. name)

	if not auth.name_ok(name) or not auth.password_ok(name) then
		return false
	end

	if auth.exists(name) then
		return false
	end

	auth.set(name, {
		   userid     = name,
		   password   = password,
		   privileges = auth.get_default_privs(),
		   last_login = math.floor(os.time())
	})

	return (type(auth.entries[name]) == "table")
 end

--- Create new entry for user in the auth table
-- @param  string name
 auth.delete_auth = function(name)
	minetest.log('info', '[auth] delete_auth ' .. name)

	if not auth.name_ok(name) then
		return false
	end

	if auth.get(name) then
		auth.entries[name] = nil

		if auth.cache_on then
			auth.cache[name] = nil
		end
	end

	return true
end

--- Set player password
-- @param  string name
-- @param  string password
-- @return boolean
auth.set_password = function(name, password)
	minetest.log('info', '[auth] set_password ' .. name)

	if not auth.name_ok(name) or not auth.password_ok(name) then
		minetest.log('error', '[auth] name or password not ok ' .. name)
		return false
	end

	if not auth.exists(name) then
		minetest.log(
			'info',
			'[auth] ' .. name .. ' does not have an auth entry.'
		)
		return auth.create_auth(
			name,
			password,
			"auth.set_password called without an account"
		)

	end

	-- Same as old password?
	if auth.get(name).password == password then
		return true
	end

	auth.set(name, {password = password})

	return (auth.get(name).password == password)
end

--- Set player privileges
-- @param  string name
-- @param  table  privileges
-- @return boolean
auth.set_privileges = function(name, privileges)
	minetest.log('info', '[auth] set_privileges ' .. name)

	assert(type(privileges) == 'table')

	if not auth.name_ok(name) then
		return false
	end

	if not auth.exists(name) then
		return false
	end

	-- No change?
	if minetest.serialize(auth.get(name).privileges) == minetest.serialize(privileges) then
		return true
	end

	auth.set(name, {privileges = privileges})

	-- Verify
	if minetest.serialize(auth.get(name).privileges) ~= minetest.serialize(privileges) then
		return false
	end

	minetest.notify_authentication_modified(name)

	return true
end

--- Here only for compatibility
-- @return boolean
auth.reload = function()
	return true
end

--- Update player's last login timestamp
-- @param  string name
-- @return boolean
auth.record_login = function(name)
	if not auth.name_ok(name) or not auth.exists(name) then
		return false
	end

	local last_login = math.floor(os.time())

	auth.set(name, {last_login = last_login})

	return (auth.get(name).last_login == last_login)
end

--- Import legacy auth.txt file and rename to auth.txt.import
auth.import_legacy = function()
    local file        = minetest.get_worldpath() .. '/auth.txt'
    local backup_file = file .. '.backup'
    local exists, err = io.open(file, 'r')
	local legacy      = exists ~= nil
	local cache_on    = auth.cache_on

	if exists then
		exists:close()
	end

	if not legacy then
		return
    end

	minetest.log('[auth] loading lagacy auth.txt file.')

	-- No caching
	auth.cache_on = false

	for line in io.lines(file) do
		auth.save_legacy_record(line)
	end

	-- Resume caching
	auth.cache_on = cache_on

    local ret, err os.rename(file, backup_file)
    assert(err == nil, err)

	minetest.log('info', '[auth] copied auth.txt to auth.txt.backup.')
end

--- Save a single record from the legacy auth.txt file
-- @param string line
auth.save_legacy_record = function(line)
	if not line:find(':') then
		return
	end

	local userid, password, privilege_string, last_login = unpack(line:split(':', true))

	if auth.exists(userid) then
		return
	end

	auth.set(userid, {
		userid     = userid,
		password   = password,
		privileges = minetest.string_to_privs(privilege_string),
		last_login = tonumber(last_login)
	})
end

--- Garbage collection. Remove cached auth entries for users no longer logged in.
-- This should run at least once per hour of uptime. If the player is not logged in
-- at that time their cache entry will be removed.
-- @param float dtime
auth.gc = function(dtime)
	auth.gc_timer = auth.gc_timer + dtime

	if auth.gc_timer < auth.gc_interval then
		return
	end

	-- Tracking in memory cache use vs expensive database use
	minetest.log('info',
		'[auth] Cache Hits ' ..
		auth.cache_hits ..
		' Database Hits ' ..
		auth.database_hits
	)

	auth.gc_timer = 0

	local connected = {}
	local count = 0 -- Needed for array count. It's a lua thing.

	-- get list of connected names
	for key, value in ipairs(minetest.get_connected_players()) do
		connected[value:get_player_name()] = true
		count = count + 1
	end

	-- Remove unused cache
	for name, entry in pairs(auth.cache) do
		if not connected[name] then
			minetest.log('verbose', '[auth] gc removing cache for ' .. name)
			auth.cache[name] = nil
		end
	end
end

--- Initialize the auth mod
-- @param _G global scope
auth.init = function(_G)
	minetest.log('[auth] Mod Initializing. License GPLv3')

	auth.import_legacy()

	minetest.register_authentication_handler(auth)
	minetest.register_globalstep(auth.gc)

	_G.auth = auth

	minetest.log('[auth] Mod loaded.')
end

auth.init(_G)
